
這篇文章會用 TDD 實作自訂 operator fun 和 infix fun,把之前零散出現的運算子觀念(day 09 的 contains、day 12 的 to、day 26 的 plus/minus)做個完整的整理
這兩組東西都不小,Kotlin 約定好的 operator 名稱是一整組,infix 函式在 stdlib 裡也散落各處,這篇不會全部都實作一遍,實際動手的是 get、invoke、plus、contains 四個 operator 加上兩個自訂的 infix
| Kotlin | C# | 備註 |
|---|---|---|
operator fun plus |
public static T operator + |
Kotlin 是成員/擴充函式,C# 是靜態方法 |
operator fun get |
this[int index] (indexer) |
Kotlin 用 get/set,C# 用 indexer |
operator fun invoke |
無直接對應 | C# 沒有讓物件當函式呼叫的語法 |
operator fun contains |
無直接對應 | C# 沒有 in 運算子 |
infix fun |
無直接對應 | C# 沒有中綴語法 |
C# 的 operator overloading 用靜態方法定義。Kotlin 用 operator 修飾詞標記在成員函式或擴充函式上,寫法比 C# 靈活
Kotlin 的 operator 是一組約定好的函式名稱。compiler 看到特定語法就會去找對應的 operator fun
| 語法 | 對應函式 | 範例 |
|---|---|---|
+a |
a.unaryPlus() |
一元正號 |
-a |
a.unaryMinus() |
一元負號 |
!a |
a.not() |
一元否定 |
a + b |
a.plus(b) |
day 26 的 myPlus |
a - b |
a.minus(b) |
day 26 的 myMinus |
a * b |
a.times(b) |
|
a / b |
a.div(b) |
|
a % b |
a.rem(b) |
|
a[i] |
a.get(i) |
今天實作 |
a[i] = v |
a.set(i, v) |
|
a in b |
b.contains(a) |
day 09 的 myContains |
a() |
a.invoke() |
今天實作 |
a > b |
a.compareTo(b) > 0 |
day 15 的 Comparable |
a..b |
a.rangeTo(b) |
|
a..<b |
a.rangeUntil(b) |
不含結尾 |
前三列是一元運算子,作用在單一操作數上。a..b 和 a..<b 是範圍運算子,差別在後者不含結尾的 b
不是所有函式都需要 operator。只有 Kotlin 規定好的那組名稱才對應得到語法糖,上表列的是最常用的幾個,另外還有 inc / dec(++ / --)、plusAssign 這組 += 系列、equals(==)、componentN(解構,day 13 看過)、iterator(for-in),以及委派屬性用的 getValue / setValue / provideDelegate
完整清單可以看官方的 operator overloading 文件
前面幾篇都是在既有的 List 上刻擴充函式,這篇不一樣。要示範的是「一個型別怎麼讓自己支援 []、()、+、in 這些語法」,四個 operator 掛在同一個 class 上才看得出全貌,所以先自己準備一個容器:MyIntList 內部就包一個 List<Int>,其他什麼都還沒有,接下來的測試都對它寫
@Test
fun `get operator with bracket syntax`() {
val list = MyIntList(listOf(10, 20, 30))
assertEquals(10, list[0])
assertEquals(30, list[2])
}
@Test
fun `invoke operator as function call`() {
val list = MyIntList(listOf(1, 2, 3, 4, 5))
val result = list { it > 3 }
assertEquals(listOf(4, 5), result)
}
@Test
fun `plus operator combines lists`() {
val a = MyIntList(listOf(1, 2))
val b = MyIntList(listOf(3, 4))
val result = a + b
assertEquals(listOf(1, 2, 3, 4), result.toList())
}
@Test
fun `contains operator with in keyword`() {
val list = MyIntList(listOf(1, 2, 3))
assertTrue(2 in list)
assertFalse(5 in list)
}
@Test
fun `get throws when index out of bounds`() {
val list = MyIntList(listOf(10, 20))
assertThrows<IndexOutOfBoundsException> { list[5] }
}
@Test
fun `invoke on empty list returns empty`() {
val list = MyIntList(emptyList())
assertEquals(emptyList<Int>(), list { it > 0 })
}
@Test
fun `plus with empty list keeps original elements`() {
val a = MyIntList(listOf(1, 2))
val empty = MyIntList(emptyList())
assertEquals(listOf(1, 2), (a + empty).toList())
}
@Test
fun `contains on empty list is always false`() {
val list = MyIntList(emptyList())
assertFalse(1 in list)
}
list[0] 對應 get,list { ... } 對應 invoke,a + b 對應 plus,2 in list 對應 contains。四種語法,四個 operator
邊界案例也一起測:索引越界要丟 IndexOutOfBoundsException,空集合的 invoke、plus、contains 行為要正確。get 的越界例外其實是底層 List 直接丟出來的,這點等實作完再看
class MyIntList(private val elements: List<Int>) {
operator fun get(index: Int): Int = elements[index]
operator fun invoke(filter: (Int) -> Boolean): List<Int> =
elements.filter(filter)
operator fun plus(other: MyIntList): MyIntList =
MyIntList(elements + other.elements)
operator fun contains(element: Int): Boolean =
elements.contains(element)
fun toList(): List<Int> = elements
}
每個 operator 都只要一行。get 讓 [] 語法可用,invoke 讓物件可以當函式呼叫,plus 讓 + 可用,contains 讓 in 可用
invoke 比較特別。它讓 MyIntList 的實例可以直接加小括號呼叫,像函式一樣。list { it > 3 } 等於 list.invoke { it > 3 }
越界測試會直接通過:get 內部呼叫 elements[index],索引超出範圍時是底層的 List 丟出 IndexOutOfBoundsException,我們不用自己檢查
stdlib 的集合不會把所有 operator 散在 class 裡,而是讓型別實作 Iterable,再把通用操作做成擴充函式。我們可以往這個方向靠:讓 MyIntList 實作 Iterable<Int>,contains、plus 這類就能改用 stdlib 既有的擴充函式,不用每個都自己寫
class MyIntList(private val elements: List<Int>) : Iterable<Int> {
operator fun get(index: Int): Int = elements[index]
operator fun invoke(filter: (Int) -> Boolean): List<Int> =
elements.filter(filter)
operator fun plus(other: MyIntList): MyIntList =
MyIntList(elements + other.elements)
override fun iterator(): Iterator<Int> = elements.iterator()
fun toList(): List<Int> = elements
}
實作 Iterable<Int> 之後,2 in list 會自動對應到 stdlib 的 Iterable<T>.contains 擴充函式,原本手寫的 contains 就可以刪掉。plus 也能換成 stdlib 的 Iterable<T>.plus,這裡保留自訂版本是為了回傳 MyIntList 而非 List。測試完全不用改,照樣綠燈,這正是 stdlib 用 Iterable 當共同抽象的好處
infix 讓二元函式可以省略 . 和 ()
// 一般呼叫
1.to("one")
// 中綴呼叫
1 to "one"
day 12 的 associate 用到了 to,day 26 的 union、intersect、subtract 也都是 infix
三個限制
// 合法
infix fun String.myRepeat(n: Int): String { ... }
// 不合法:兩個參數
// infix fun String.between(start: Int, end: Int): String ← 編譯錯誤
@Test
fun `myTo creates pair`() {
val pair = "name" myTo "Alice"
assertEquals(Pair("name", "Alice"), pair)
}
@Test
fun `myRepeat repeats string`() {
val result = "ab" myRepeat 3
assertEquals("ababab", result)
}
@Test
fun `myRepeat with zero returns empty string`() {
val result = "ab" myRepeat 0
assertEquals("", result)
}
myRepeat 0 是邊界案例,重複零次應該回傳空字串。myTo 不會失敗,任何兩個值都能配對,所以不另外測例外
infix fun <A, B> A.myTo(that: B): Pair<A, B> = Pair(this, that)
infix fun String.myRepeat(n: Int): String {
val sb = StringBuilder()
repeat(n) { sb.append(this) }
return sb.toString()
}
myTo 跟 stdlib 的 to 幾乎一樣。用泛型讓任何型別都可以配對
myRepeat 用 infix 讓語法讀起來像英文:"ab" myRepeat 3。比 "ab".myRepeat(3) 更直覺。repeat(0) 不會跑迴圈,sb 維持空字串,邊界測試直接通過
stdlib 早就有 String.repeat(n),自己疊 StringBuilder 是多餘的,可以直接委派過去
infix fun String.myRepeat(n: Int): String = this.repeat(n)
stdlib 的 repeat 在 n 為負數時會丟 IllegalArgumentException,我們的版本委派之後也跟著有了這個行為,不用自己處理
myTo 已經跟 stdlib 的 to 一致,沒有東西可以再簡化
適合用 infix 的場景:函式名稱讀起來像介系詞或動詞,兩個操作數之間有明確的關係
// 好的 infix 用法
1 to "one" // A 配對 B
listA union listB // A 聯集 B
route shouldBe 200 // 測試框架常見
// 不適合的 infix 用法
// string process config ← 語意不清
| operator | infix | |
|---|---|---|
| 語法 | 符號(+、[]、in) |
函式名稱(to、union) |
| 函式名稱 | 固定的(plus、get、contains) | 自訂 |
| 參數數量 | 由運算子決定 | 一定是一個 |
| 修飾詞 | operator |
infix |
operator 是把函式對應到符號語法,名稱是固定的。infix 是把函式呼叫變成中綴語法,名稱自己取
operator 和 infix 都是純粹的編譯期語法糖。compiler 在解析階段把符號或中綴語法翻譯成一般的函式呼叫,JVM 指令層面看到的就是普通的方法呼叫(修飾詞本身還是會記在 Kotlin 的 @Metadata annotation 裡,不然別的 module 不會知道這個函式能用中綴語法叫)
以 a + b 為例,compiler 解糖後等於 a.plus(b);2 in list 等於 list.contains(2);"ab" myRepeat 3 等於 "ab".myRepeat(3)。把這幾行用 IntelliJ 的「Show Kotlin Bytecode → Decompile」反編譯回 Java,看到的就是普通的方法呼叫
// "ab" myRepeat 3 反編譯後(示意)
MyInfixKt.myRepeat("ab", 3);
這也解釋了兩件事,第一,operator 和 infix 沒有任何 runtime 成本,跟手寫函式呼叫一樣快
第二,因為底層是同一個函式,你可以同時用兩種寫法呼叫:1.to("one") 和 1 to "one" 跑的是完全相同的程式碼
operator 給開發者極大的自由,但用過頭就是災難
Kotlin 社群對自訂 operator 一直保持警覺
a + b 對 List 是合併、對 Set 是聯集、對自訂 class 可能做任何事,讀者要先看實作才知道意思+ 跟內建加法看起來一樣,grep 不到自訂 plus 的呼叫點C# 的 operator overloading 也有同樣爭議,.NET Framework Design Guidelines 明確建議:只有「跟基本型別行為類似」的場景才用 operator overloading,如複數、矩陣、向量。對 domain object 一律用具名方法
Kotlin stdlib 自己也很節制,Collection 有 +、-、in,Map 有 [],但這些都是業界慣例(數學集合運算或索引存取)
實作建議:operator 只用在數學概念(向量、複數)、不可變值物件、集合運算;業務 entity 永遠用具名方法;infix 比 operator 安全一點,因為函式名還在
原始碼位置:kotlin 的 Tuples.kt、kotlin.text 的 StringsJVM.kt
stdlib 的 to 函式
public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)
跟我們的 myTo 一模一樣。stdlib 的 union、intersect、subtract 也都是 infix,day 26 看過了
repeat 就不一樣了
public actual fun CharSequence.repeat(n: Int): String {
require(n >= 0) { "Count 'n' must be non-negative, but was $n." }
return when (n) {
0 -> ""
1 -> this.toString()
else -> {
when (length) {
0 -> ""
1 -> this[0].let { char -> String(CharArray(n) { char }) }
else -> {
val sb = StringBuilder(n * length)
for (i in 1..n) {
sb.append(this)
}
sb.toString()
}
}
}
}
}
最大的差別是它沒有 infix。我們的 myRepeat 加上去只是為了示範中綴語法,但 "ab" repeat 3 其實不好讀,repeat 是動詞、後面接的是次數,兩個操作數之間沒有介系詞式的關係,剛好是前面那節「什麼時候適合用 infix」的反例,stdlib 選擇不加,可能是對的
另外兩個細節,receiver 是 CharSequence 不是 String,所以 StringBuilder 這類也適用,開頭的 require(n >= 0) 就是我們委派過去之後拿到的那個 IllegalArgumentException,後面兩層 when 全是捷徑,重複 0 次或字串本身是空的直接回傳 "",重複 1 次直接 toString(),單一字元用 CharArray 填滿,全都避開了 StringBuilder,只有真正需要疊字串的情況才走到最後一支,而且連容量都先算好 n * length
順帶一提,這個函式是 expect/actual:宣告在 common 的 TextH.kt,上面這份是 JVM 的實作,JS、Native、Wasm 各有自己的版本
operator 把函式綁定到符號語法上,名稱是 Kotlin 規定好的
invoke 讓物件可以當函式用,get 讓物件支援 []。infix 是另一回事,它讓自訂名稱的函式可以用中綴語法呼叫,讀起來更像自然語言,兩者都是語法糖,底層就是普通的函式呼叫
下一篇進入泛型進階,看 out/in 型變和 reified 怎麼讓泛型更強大
同步刊登於 Blog
圖片來源:AI 產生